iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Rust

Rust 後端入門系列 第 23

Day 23 Axum 專案整合測試

  • 分享至 

  • xImage
  •  

大家是否認為使用 curl 的測試效率太低,也不好檢查結果,現在我們將要學習高效的測試方式。

目標

  • 用整合測試(integration tests)覆蓋 handlers 的成功路徑與錯誤路徑。
  • 啟動真實 HTTP server(或接近真實的環境),用 reqwest 模擬 client 發送請求,確認路由、middleware、序列化、SQL、Redis 行為整合正確。

為什麼要做整合測試

  1. 提高 confidence
    • 單元測試可以快速驗證函式內部邏輯(如密碼雜湊/驗證、錯誤 mapping)。
    • 整合測試能驗證路由、序列化、DB 查詢、Redis 快取、middleware 行為能正確整合在一起。
  2. 捕捉真實環境問題
    • 單元測試抓不到的整合錯誤(例如 JSON field 名稱 mismatch、routing path 參數型態、DB migration 差異)會在整合測試被捕捉到。
  3. 減少回歸(regressions)
    • 每次改動後跑測試可以早期發現破壞現有功能的改動。
  4. 可自動化(CI)
    • 測試可在 CI (GitHub Actions/ GitLab CI) 中執行,確保合併前通過。
  5. 文件化行為(Living documentation)
    • 測試案例同時也是對 API 行為的示範:哪些情況會回 201 / 200 / 401 / 404 / 204。

整體策略

  1. 把 Router 與依賴注入(PgPool、Redis 連線)包成可以在測試中重複建立的函式(工廠模式)。
  2. 單元測試(#[cfg(test)]):
    • 對 handler 函式做小範圍測試:使用測試專用的 DB 連線(或用 sqlx 提供的 mock/交易 rollback),或把 DB/Redis 抽成 trait 在測試中用 fake 實作替代。
    • 利用 tokio::test 直接呼叫 handler(不經過網路層),快速且穩定。
  3. 整合測試(tests/…):
    • 啟動一個 listener(127.0.0.1:0 隨機 port),把 Router 綁上真實 PgPool 與 Redis(建議使用測試資料庫/測試 Redis DB )。
    • 用 reqwest async client 向實際 HTTP endpoint 發送請求,檢查回傳 status 與 body。
    • 測試時每個 case 建立各自的 DB state(或使用 transaction + rollback),確保可重複執行。

把 App 建構封裝成可被測試的工廠函式

先在 main.rs 或一個 lib.rs 提供一個建立 app 的 function,方便測試時呼叫:

src/app.rs (新增)

use axum::{Router, routing::{get, post, put, delete}, Extension};
use sqlx::PgPool;
use redis::aio::MultiplexedConnection;

pub fn create_app(pool: PgPool, redis: MultiplexedConnection) -> Router {
    Router::new()
        .route("/users", post(crate::handlers::create_user).get(crate::handlers::list_users))
        .route("/users/{id}", get(crate::handlers::get_user).put(crate::handlers::update_user).delete(crate::handlers::delete_user))
        .route("/users/login", post(crate::handlers::login))
        .layer(Extension(pool))
        .layer(Extension(redis))
}

新增 src/lib.rs,以便測試可以使用 create_app

pub mod handlers;
pub mod models;
pub mod cache;
pub mod password;
pub mod app;

範例

在 Cargo.toml 添加使用的依賴套件

[dev-dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
uuid = { version = "1", features = ["v4"] }

整合測試(放在 tests/integration_tests.rs)

  • 用 reqwest 模擬真實 HTTP client。
  • 啟動 server 在隨機 port(127.0.0.1:0)並把 listener 的本地地址給 reqwest。
  • 每個測試建立獨立 DB 狀態或每個 test 使用獨立資料庫(例如測試開頭創建一個以 UUID 命名的 DB,再執行 migrations)。

tests/integration_tests.rs

use reqwest::StatusCode;
use sqlx::{PgPool, Executor};
use std::net::SocketAddr;
use tokio::net::TcpListener;
use uuid::Uuid;
use sqlx::migrate::MigrateDatabase;
use serde_json::Value;
use redis::Client as RedisClient;

async fn spawn_app() -> (String, PgPool, redis::aio::MultiplexedConnection) {
    // 建立測試資料庫(用 TEST_DATABASE_URL 隔離真實資料庫)
    let base_url = std::env::var("TEST_DATABASE_URL_BASE").expect("TEST_DATABASE_URL_BASE");
    let db_name = format!("test_db_{}", Uuid::new_v4().to_string().replace("-", ""));
    let db_url = format!("{}/{}", base_url, db_name);

    if !sqlx::postgres::Postgres::database_exists(&db_url).await.unwrap_or(false) {
        sqlx::Postgres::create_database(&db_url).await.unwrap();
    }

    let pool = PgPool::connect(&db_url).await.unwrap();
    // 執行 migrations
    sqlx::migrate!("./migrations").run(&pool).await.unwrap();

    // Redis - 指定 TEST_REDIS_URL,隔離開發環境
    let redis_url = std::env::var("TEST_REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/1".to_string());
    let redis_client = RedisClient::open(redis_url.as_str()).unwrap();
    let redis_conn = redis_client.get_multiplexed_tokio_connection().await.unwrap();

    // 使用不同 port 避免占用真實專案的 port
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
	        .await
	        .unwrap();
    let addr = listener.local_addr().unwrap();
    let app = sqlx_connect_demo::app::create_app(pool.clone(), redis_conn.clone());
    
	let server_handle = tokio::spawn(async move {
        axum::serve(listener, app)
	    .await
	    .unwrap();
    });
	
	
    (format!("http://{}", addr), pool, redis_conn)
}

#[tokio::test]
async fn integration_create_get_update_delete_user_flow() {
    let (base_url, pool, _redis) = spawn_app().await;

    let client = reqwest::Client::new();

    // POST /users (create)
    let res = client.post(format!("{}/users", base_url))
        .json(&serde_json::json!({
            "username": "user1",
            "email": "user1@a.com",
            "password": "password"
        }))
        .send()
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::CREATED);
    let body: Value = res.json().await.unwrap();
    let id = body["id"].as_i64().unwrap();

    // GET /users/{id} - 成功
    let res = client.get(format!("{}/users/{}", base_url, id)).send().await.unwrap();
    assert_eq!(res.status(), StatusCode::OK);

    // PUT /users/{id} - 成功
    let res = client.put(format!("{}/users/{}", base_url, id))
        .json(&serde_json::json!({"username": "new_user1"}))
        .send().await.unwrap();
    assert_eq!(res.status(), StatusCode::OK);

    // POST /users/login -> 成功
    let res = client.post(format!("{}/users/login", base_url))
        .json(&serde_json::json!({
            "username_or_email": "new_user1",
            "password": "password"
        }))
        .send().await.unwrap();
    assert_eq!(res.status(), StatusCode::OK);

    // DELETE /users/{id} -> 成功
    let res = client.delete(format!("{}/users/{}", base_url, id)).send().await.unwrap();
    assert_eq!(res.status(), StatusCode::NO_CONTENT);

    // GET 已被刪除的資料 -> 404
    let res = client.get(format!("{}/users/{}", base_url, id)).send().await.unwrap();
    assert_eq!(res.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn integration_login_failures() {
    let (base_url, _pool, _redis) = spawn_app().await;
    let client = reqwest::Client::new();

    // 登入不存在的用戶 -> 401
    let res = client.post(format!("{}/users/login", base_url))
        .json(&serde_json::json!({
            "username_or_email": "not_exists",
            "password": "password"
        }))
        .send().await.unwrap();
    assert_eq!(res.status(), StatusCode::UNAUTHORIZED);

    // 建立用戶 -> 成功
    let res = client.post(format!("{}/users", base_url))
        .json(&serde_json::json!({
            "username": "user1",
            "email": "user1@a.com",
            "password": "truepass"
        }))
        .send()
        .await.unwrap();
    assert_eq!(res.status(), StatusCode::CREATED);

	// 登入,但密碼錯誤 -> 401
    let res = client.post(format!("{}/users/login", base_url))
        .json(&serde_json::json!({
            "username_or_email": "user1",
            "password": "falsepass"
        }))
        .send()
        .await.unwrap();
    assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}

這些測試涵蓋:

  • 預期成功的測試:建立使用者、查詢、更新、登入(成功)、刪除、刪除後查不到。
  • 預期錯誤:登入密碼錯誤、無此使用者。

注意:

  • 啟動 server 時,必須使用 tokio::spawn。我們在 test 裡啟動 server,然後用對 server 發 request。這兩件事都要在同一 Tokio runtime 中運行,所以 server 必須在背景 task 中運行,client 才能在主測試任務中發送請求。

測試

set TEST_DATABASE_URL_BASE=DATABASE_URL=postgres://user:password@127.0.0.1:5432
cargo test --test integration_tests

輸出結果

running 2 tests
test integration_login_failures ... ok
test integration_create_get_update_delete_user_flow ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.39s

可以看到兩個測試都通過了


上一篇
Day 22 Axum專案導入 Validator
下一篇
Day 24 Axum專案加入JWT與驗證
系列文
Rust 後端入門25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言